레거시 코드에 Characterization Test 추가하기

레거시 코드에 Characterization Test 추가하기

한눈에 보기

Characterization Test는 현재 코드가 옳다고 선언하는 테스트가 아니다. 특정 입력에서 지금 관찰되는 출력과 부수 효과를 기록해 변경 전후의 차이를 드러내는 안전망이다. 바꾸려는 경계부터 좁게 잡고, 시간·랜덤·ID 같은 변동값을 제어하며, 정상·경계·이상 입력을 표로 수집한다. 잘못된 기존 동작은 별도 테스트와 이슈로 명시한 뒤 의도적으로 계약을 바꾼다.

테스트가 거의 없는 오래된 함수를 리팩터링해야 할 때 가장 어려운 질문은 “이 코드가 무엇을 하려는가?”가 아니다. 더 현실적인 질문은 “이 코드를 바꾸면 무엇이 달라지고, 그 차이가 버그인지 개선인지 어떻게 알 수 있는가?”다.

문서와 원래 개발자가 없고 운영 데이터만 남은 코드에서는 요구사항을 완전히 복원하기 어렵다. 그렇다고 긴 함수와 중복 조건을 그대로 둘 수도 없다. 이때 Characterization Test를 먼저 만들면 현재 동작을 임시 기준선으로 고정하고 작은 단계로 구조를 바꿀 수 있다.

이 글의 코드는 특정 저장소에서 가져오지 않았다. 오래된 주문 정산 모듈을 가정해 테스트를 만드는 과정을 예시로 재구성했다.

목차

Characterization Test는 무엇을 보장하는가

Characterization Test는 코드의 현재 특성을 관찰해 테스트로 기록한다. 중요한 단어는 현재관찰 가능이다.

예를 들어 다음 레거시 함수가 있다고 하자.

export function calculatePayable(order, customer, now = new Date()) {
  let amount = Number(order.amount || 0);

  if (order.status === "CANCELLED") return 0;
  if (customer.grade === "VIP") amount *= 0.9;
  if (order.coupon) amount -= parseInt(order.coupon.value, 10) || 0;
  if (now.getDay() === 1 && amount > 100000) amount -= 3000;
  if (amount < 0) amount = 0;

  return Math.floor(amount / 10) * 10;
}

코드를 읽으면 질문이 생긴다.

바로 깔끔한 정책 객체로 고치면 이러한 동작이 함께 바뀔 수 있다. 먼저 실제 출력을 테스트로 남긴다.

import { describe, expect, test } from "vitest";
import { calculatePayable } from "./legacy-payable.js";

describe("calculatePayable current behavior", () => {
  test("applies VIP discount before fixed coupon", () => {
    const monday = new Date("2026-08-24T03:00:00Z");
    const result = calculatePayable(
      { amount: 120000, status: "PAID", coupon: { value: "5000" } },
      { grade: "VIP" },
      monday,
    );

    expect(result).toBe(100000);
  });
});

이 테스트가 “VIP 할인 뒤 쿠폰 차감이 올바른 사업 정책”을 증명하지는 않는다. 다만 리팩터링 후 100,000원이 99,000원으로 바뀌면 차이가 있음을 알려 준다.

안전망의 범위

Characterization Test는 기존 동작을 보존하는 회귀 감지기다. 요구사항의 타당성, 보안, 성능, 전체 입력 공간의 정확성을 자동으로 증명하지 않는다.

정답 테스트와 다른 점

일반적인 단위 테스트는 요구사항에서 기대값을 도출한다.

요구사항
  VIP는 상품 금액의 10%를 할인한다.
  정액 쿠폰은 할인 뒤 적용한다.

기대값
  (120,000 × 0.9) - 5,000 = 103,000

Characterization Test는 실행 결과에서 기대값을 발견한다.

현재 코드 실행
  결과 = 100,000

테스트
  현재 결과가 100,000임을 기록

조사
  월요일 3,000원 추가 할인이 숨어 있음을 확인
관점 요구사항 기반 테스트 Characterization Test
기대값 출처 명세·업무 규칙 현재 실행 결과
주목적 올바른 동작 증명 변경으로 생긴 차이 탐지
작성 시점 구현 전후 이해하기 어려운 변경 전
실패 의미 구현이 요구사항과 다름 이전과 달라짐
수명 계약이 유지되는 동안 이해 후 정식 테스트로 발전 가능

둘 중 하나를 선택하는 관계는 아니다. 레거시 코드의 현재 동작을 확보한 뒤 중요한 규칙은 요구사항 기반 테스트로 다시 이름 붙이고, 우연한 구현 세부는 제거한다.

먼저 바꾸려는 경계를 고른다

거대한 서비스 전체를 한 번에 snapshot으로 고정하면 테스트는 만들기 쉽지만 실패 원인을 찾기 어렵다. 리팩터링하려는 변경 지점을 기준으로 가장 좁고 안정적인 관찰 경계를 찾는다.

flowchart LR
    H[HTTP 요청] --> C[Controller]
    C --> S[Legacy Service]
    S --> D[(Database)]
    S --> P[Payment API]
    S --> E[Event Publisher]

서비스 내부 조건문을 정리하려는 경우 후보는 다음과 같다.

테스트 경계 장점 약점
순수 함수 빠르고 실패 원인이 분명 코드에 진입점이 없을 수 있음
service public method 실제 조합을 보존 DB·시간·외부 API 제어 필요
HTTP endpoint 사용자 계약에 가까움 인증·직렬화 등 잡음이 큼
전체 프로세스 golden master 빠르게 넓은 동작 수집 차이가 너무 크고 느림

좋은 출발점은 바꾸려는 코드를 통과하면서도 외부 변동은 최소인 public seam이다.

함수를 직접 부를 수 없다면 억지로 private 메서드마다 테스트를 붙이지 않는다. HTTP 경계에서 몇 개의 큰 사례를 먼저 확보한 뒤, 내부 의존성을 분리하면서 더 작은 테스트를 추가할 수 있다.

변경 반경으로 우선순위를 정한다

파일 전체를 커버하려 하지 말고 다음 리팩터링이 건드릴 분기와 그 분기로 들어오는 입력부터 고정한다. Characterization Test도 목적 없는 100% coverage 경쟁이 되면 유지 비용만 늘어난다.

현재 동작을 관찰할 수 있게 만든다

테스트가 어려운 레거시 코드는 결과가 반환값 하나로 끝나지 않는다.

async function closeOrder(orderId) {
  const order = await db.orders.find(orderId);
  order.status = "CLOSED";
  await db.orders.save(order);
  await mailer.send(order.email, "closed");
  console.log("closed", orderId);
}

관찰 가능한 결과는 최소 세 가지다.

반환값이 undefined라고 toBeUndefined()만 검사하면 핵심 동작은 전혀 고정되지 않는다. 테스트 경계에서 의존성 호출을 기록한다.

function createRecordingDependencies() {
  const calls = [];

  return {
    calls,
    db: {
      orders: {
        find: async (id) => ({
          id,
          status: "PAID",
          email: "user@example.invalid",
        }),
        save: async (order) => {
          calls.push({ type: "db.save", order: structuredClone(order) });
        },
      },
    },
    mailer: {
      send: async (to, template) => {
        calls.push({ type: "mail.send", to, template });
      },
    },
  };
}

프로덕션 함수가 전역 DB를 직접 참조하면 당장 생성자 주입으로 전면 리팩터링하기보다 최소한의 wrapper seam부터 만든다.

// 동작을 바꾸지 않는 첫 단계
export const legacyDeps = {
  findOrder: (id) => db.orders.find(id),
  saveOrder: (order) => db.orders.save(order),
  sendMail: (to, template) => mailer.send(to, template),
};

테스트에서 wrapper를 대체할 수 있게 하고, Characterization Test가 생긴 뒤 정식 의존성 주입으로 옮긴다.

입력 공간을 사례 표로 만든다

레거시 함수에 임의의 예제 하나만 넣으면 가장 흔한 경로만 고정된다. 코드를 읽으며 입력 축과 분기를 표로 만든다.

상태: PAID / CANCELLED / 알 수 없음 / 누락
등급: VIP / NORMAL / null
쿠폰: 없음 / 정상 숫자 / 0 / 음수 / 잘못된 문자열
금액: 0 / 10원 미만 / 경계값 / 음수 / 문자열
요일: 월요일 / 그 외

모든 조합은 너무 많으므로 분기와 경계값을 대표하는 사례를 고른다.

사례 status grade amount coupon 시간 현재 출력
기본 PAID NORMAL 12,340 없음 화요일 12,340
VIP PAID VIP 12,340 없음 화요일 11,100
쿠폰 파싱 PAID NORMAL 12,340 "400" 화요일 11,940
할인 경계 PAID VIP 120,000 "5000" 월요일 100,000
음수 방지 PAID NORMAL 100 "500" 화요일 0
취소 CANCELLED VIP 120,000 "5000" 월요일 0

테이블 기반 테스트로 옮기면 누락된 축을 보기 쉽다.

describe.each([
  {
    name: "normal order",
    order: { amount: 12340, status: "PAID" },
    customer: { grade: "NORMAL" },
    now: "2026-08-25T03:00:00Z",
    expected: 12340,
  },
  {
    name: "negative total is clamped",
    order: {
      amount: 100,
      status: "PAID",
      coupon: { value: "500" },
    },
    customer: { grade: "NORMAL" },
    now: "2026-08-25T03:00:00Z",
    expected: 0,
  },
])("$name", ({ order, customer, now, expected }) => {
  test("preserves observed payable", () => {
    expect(
      calculatePayable(order, customer, new Date(now)),
    ).toBe(expected);
  });
});

테스트 이름에는 입력을 나열하기보다 보존하려는 관찰을 적는다. 나중에 실패했을 때 무엇이 변했는지 바로 이해할 수 있다.

Golden Master를 사용할 때

출력이 큰 JSON, 텍스트 보고서, 파일 묶음이라면 필드를 하나씩 assert하기 어렵다. 대표 입력의 전체 출력을 저장한 golden master 또는 snapshot이 초기 안전망이 될 수 있다.

test("legacy monthly statement output", async () => {
  const statement = await generateStatement(fixtureAccount, fixedContext);
  const normalized = normalizeStatement(statement);

  expect(normalized).toMatchFileSnapshot(
    "./fixtures/monthly-statement.approved.json",
  );
});

snapshot을 쓰기 전에 변동값을 제거한다.

function normalizeStatement(value) {
  return {
    accountId: "<ACCOUNT_ID>",
    currency: value.currency,
    lines: value.lines.map((line) => ({
      type: line.type,
      amount: line.amount,
      reason: line.reason,
    })),
    totals: value.totals,
    generatedAt: "<FIXED_TIME>",
  };
}

Golden Master가 적합한 경우는 다음과 같다.

다음 경우에는 부적합하다.

snapshot 갱신은 수정이 아니다

테스트가 실패했다고 update snapshots를 실행하면 안전망이 사라진다. diff의 각 변화가 의도한 계약 변경인지 확인한 뒤 승인해야 한다.

큰 snapshot으로 시작했더라도 동작을 이해하면 중요한 규칙을 작은 명시적 assertion으로 옮긴다.

변동하는 값을 안정화한다

시간, 난수, UUID, 정렬 순서, locale, 환경 변수는 characterization 결과를 매번 바꿀 수 있다.

시간

test("uses the current monthly policy", () => {
  vi.useFakeTimers();
  vi.setSystemTime(new Date("2026-08-28T12:00:00Z"));

  expect(runLegacyPolicy(input)).toEqual(expected);

  vi.useRealTimers();
});

가능하면 전역 fake timer보다 clock을 인자로 전달한다.

type Clock = { now(): Date };

const fixedClock: Clock = {
  now: () => new Date("2026-08-28T12:00:00Z"),
};

ID와 난수

type Runtime = {
  nextId(): string;
  random(): number;
};

const deterministicRuntime: Runtime = {
  nextId: () => "id_example_001",
  random: () => 0.42,
};

순서

DB의 ORDER BY 없는 결과 순서는 계약으로 보면 안 된다. 사용자에게 순서가 중요하다면 프로덕션 쿼리에 명시하고, 중요하지 않다면 테스트에서 key로 정렬한다.

const normalized = result.items
  .map(pickStableFields)
  .sort((a, b) => a.code.localeCompare(b.code));

locale과 timezone

테스트 프로세스의 locale과 timezone을 고정하거나 formatter에 명시적으로 전달한다. 개발자 노트북과 CI가 서로 다른 기본 설정을 쓰면 현재 동작을 잘못 기록할 수 있다.

변동값을 모두 <ignored>로 바꾸면 실제 회귀도 숨긴다. 주문 ID 형식이 계약이라면 고정 generator로 정확히 검증하고, 아무 의미 없는 trace ID만 정규화한다.

외부 의존성에 seam을 만든다

레거시 코드는 외부 모듈을 함수 안에서 생성하는 경우가 많다.

export async function settle(orderId) {
  const client = new PaymentSdk(process.env.PAYMENT_TOKEN);
  const order = await globalDatabase.load(orderId);
  return client.capture(order.paymentKey, order.amount);
}

Characterization Test를 위해 처음부터 계층을 완벽하게 다시 설계하면 테스트 없이 큰 변경을 하게 된다. 작은 seam을 먼저 추가한다.

export async function settle(
  orderId,
  deps = {
    loadOrder: (id) => globalDatabase.load(id),
    capture: (key, amount) =>
      new PaymentSdk(process.env.PAYMENT_TOKEN).capture(key, amount),
  },
) {
  const order = await deps.loadOrder(orderId);
  return deps.capture(order.paymentKey, order.amount);
}

기본 동작은 그대로이고 테스트만 가짜 의존성을 넘긴다.

test("captures the current gross amount", async () => {
  const calls = [];
  const deps = {
    loadOrder: async () => ({
      paymentKey: "pay_example",
      amount: 18700,
    }),
    capture: async (key, amount) => {
      calls.push({ key, amount });
      return { status: "captured", receiptId: "receipt_example" };
    },
  };

  await settle("order_example", deps);

  expect(calls).toEqual([
    { key: "pay_example", amount: 18700 },
  ]);
});

seam을 넣는 첫 커밋과 실제 리팩터링 커밋을 나누면 리뷰하기 쉽다. 첫 커밋은 기본 실행 경로가 이전과 같다는 것을 확인한다.

데이터베이스 부수 효과를 고정한다

DB를 mock한 단위 테스트는 어떤 repository 메서드가 호출됐는지는 보여 주지만 실제 unique constraint, trigger, transaction 경계는 놓친다. 중요한 레거시 변경에는 테스트 DB를 사용한 통합 characterization이 필요하다.

정산 명령이 다음 세 테이블을 바꾼다고 하자.

orders.status
ledger_entries
outbox_events

최종 상태와 이벤트를 함께 검사한다.

SELECT status, settled_amount
FROM orders
WHERE id = 'order_example_001';

SELECT entry_type, amount, reference_id
FROM ledger_entries
WHERE order_id = 'order_example_001'
ORDER BY id;

SELECT event_type, aggregate_id, payload
FROM outbox_events
WHERE aggregate_id = 'order_example_001'
ORDER BY id;

테스트 코드에서는 안정적인 필드만 비교한다.

expect(await readDatabaseEffects(db, orderId)).toEqual({
  order: {
    status: "SETTLED",
    settledAmount: 18700,
  },
  ledger: [
    {
      type: "PAYMENT_CAPTURED",
      amount: 18700,
      referenceId: "order_example_001",
    },
  ],
  outbox: [
    {
      eventType: "order.settled",
      aggregateId: "order_example_001",
    },
  ],
});

테스트마다 transaction rollback을 사용할 수 있지만, 프로덕션 코드 자체가 commit과 새 connection을 사용하면 격리가 깨질 수 있다. 독립 schema나 컨테이너 DB로 실제 경계를 확인한다.

SQL 문장이 아니라 결과를 고정한다

ORM이 생성한 SQL 문자열 전체를 snapshot으로 잡으면 인덱스 힌트나 alias 같은 구현 변경에도 깨진다. 업무적으로 관찰 가능한 행, 제약 위반, 이벤트 결과를 검사한다.

발견한 버그를 테스트에 어떻게 남길까

Characterization 과정에서 명백해 보이는 버그를 자주 발견한다.

// 쿠폰 문자열 "1000won"이 parseInt 때문에 1000으로 처리된다.
expect(
  calculatePayable(
    {
      amount: 5000,
      status: "PAID",
      coupon: { value: "1000won" },
    },
    { grade: "NORMAL" },
    tuesday,
  ),
).toBe(4000);

이 결과를 무조건 영구 계약으로 만들거나, 테스트 없이 즉시 고치는 두 극단을 피한다.

  1. 현재 동작을 명시적인 이름으로 기록한다.
  2. 왜 위험한지 이슈와 연결한다.
  3. 실제 운영 데이터와 호출자를 조사한다.
  4. 새 요구사항 테스트를 작성한다.
  5. 코드와 기대값을 한 변경에서 의도적으로 바꾼다.
test.fails(
  "invalid coupon text should be rejected instead of partially parsed",
  () => {
    expect(validateCoupon("1000won")).toEqual({
      ok: false,
      code: "INVALID_COUPON_VALUE",
    });
  },
);

테스트 프레임워크의 fails, skip, todo 정책은 팀 규칙에 맞춘다. CI에서 영원히 무시되는 테스트가 되지 않도록 추적 이슈와 제거 조건을 적는다.

버그 수정으로 API 계약이 바뀐다면 기존 characterization 기대값을 조용히 덮지 않는다.

- test("currently accepts numeric prefix in coupon text", ...)
+ test("rejects coupon values containing non-digits", ...)

커밋 메시지나 ADR에 이전과 새 동작, 마이그레이션 영향을 함께 남긴다.

테스트를 발판으로 리팩터링한다

안전한 순서는 작은 행동 보존 단계의 반복이다.

flowchart LR
    A[변경할 경로 선택] --> B[현재 동작 관찰]
    B --> C[Characterization Test 추가]
    C --> D[작은 구조 변경]
    D --> E{테스트 통과?}
    E -->|예| F[다음 구조 변경]
    F --> E
    E -->|아니오| G[의도한 변화인지 조사]
    G -->|회귀| H[코드 수정]
    G -->|계약 변경| I[요구사항과 테스트 갱신]

예시 함수는 다음 단계로 나눌 수 있다.

  1. 전역 시간을 now 인자로 노출한다.
  2. 금액 파싱을 함수로 추출한다.
  3. 할인 규칙을 각각 이름 있는 함수로 추출한다.
  4. 정규화된 입력 타입을 도입한다.
  5. 새 정책 엔진과 기존 결과를 비교하는 differential test를 만든다.
  6. 충분히 같아지면 호출자를 새 구현으로 전환한다.

기존과 새 구현을 같은 사례에 실행하는 테스트가 유용하다.

test.each(characterizationCases)(
  "$name",
  ({ order, customer, now }) => {
    const legacy = calculatePayable(order, customer, now);
    const modern = pricingPolicy.calculate(
      normalizeInput(order, customer, now),
    );

    expect(modern).toBe(legacy);
  },
);

의도적으로 바꾸려는 사례는 예외 목록으로 숨기지 않고 새 요구사항 테스트로 분리한다.

리팩터링이 끝나면 characterization test를 모두 지울 필요는 없다. public 계약을 잘 표현하는 사례는 이름과 fixture를 다듬어 회귀 테스트로 남긴다. private 구조에 묶인 테스트는 더 상위 행동 테스트로 대체하고 제거한다.

Characterization Test의 품질을 확인한다

테스트가 통과한다는 사실만으로 실제 회귀를 잡는지는 알 수 없다. 작은 mutation을 만들어 실패하는지 확인한다.

- if (customer.grade === "VIP") amount *= 0.9;
+ if (customer.grade === "VIP") amount *= 0.8;

관련 테스트가 실패해야 한다. 조건 순서 변경, 경계 비교 >에서 >=, 반올림 방식 변경 같은 mutation도 시험한다.

확인할 질문은 다음과 같다.

coverage는 입력되지 않은 코드를 찾는 힌트지만 품질 자체는 아니다. 한 테스트가 100줄 함수를 지나갔다고 모든 경계가 검증된 것은 아니다.

line coverage: 실행됐는가?
branch coverage: 각 조건 방향을 지났는가?
mutation score: 의미 있는 변경을 잡았는가?
domain cases: 실제 위험 사례를 포함하는가?

네 관점을 함께 보면 단순 숫자보다 안전망의 빈 곳을 찾기 쉽다.

취약한 테스트가 되는 패턴

private 메서드 호출을 그대로 고정한다

리팩터링의 목적이 내부 구조 변경인데 private 메서드 이름과 호출 순서를 assert하면 올바른 구조 개선도 막힌다. 외부에서 관찰되는 결과를 우선한다.

mock 호출 수만 검사한다

repository.save가 한 번 호출됐다는 사실은 저장된 데이터가 올바르다는 뜻이 아니다. 인자와 최종 DB 상태를 확인한다.

실제 운영 응답을 통째로 fixture에 복사한다

개인정보, 토큰, 내부 URL이 테스트 저장소에 남을 수 있다. 최소한의 합성 fixture를 만들고 값의 분포와 구조만 반영한다.

모든 예외를 같은 기대값으로 만든다

레거시 함수가 어떤 오류든 빈 배열을 반환한다고 이를 한 테스트로 굳히면 DB 장애와 정상 empty 결과를 구분할 개선이 어려워진다. 오류 유형별 현재 행동을 따로 기록한다.

snapshot이 너무 크다

수만 줄 diff는 아무도 검토하지 않고 승인 버튼만 누르게 만든다. 하위 섹션으로 나누고 의미 있는 필드만 남긴다.

테스트 순서에 의존한다

공유 fixture를 이전 테스트가 수정해 다음 기대값을 만드는 구조는 현재 동작조차 안정적으로 설명하지 못한다. 각 사례의 입력과 상태를 독립적으로 초기화한다.

네트워크 실제 서비스에 의존한다

외부 API 응답이 바뀌면 기준선도 흔들린다. 계약 fixture나 기록된 응답을 사용하되 민감 정보를 제거하고 만료 정책을 둔다. 외부 계약 자체는 별도 contract test로 확인한다.

운영 트래픽에서 사례를 수집할 때

어떤 입력이 중요한지 모를 때 운영 로그나 shadow 실행에서 사례를 수집할 수 있다. 하지만 운영 데이터를 그대로 테스트 fixture로 옮기면 안 된다.

안전한 흐름은 다음과 같다.

flowchart LR
    A[운영 입력 분포 관측] --> B[민감 필드 제거]
    B --> C[동등 클래스 분류]
    C --> D[합성 fixture 생성]
    D --> E[현재 구현 출력 기록]
    E --> F[사람이 diff 검토]
    F --> G[characterization suite 반영]

운영에서 필요한 것은 개별 사용자의 실제 주문이 아니라 다음과 같은 입력 특성이다.

shadow comparison으로 새 구현을 운영 트래픽에 읽기 전용으로 실행할 수도 있다.

{
  "event": "pricing_shadow_diff",
  "caseClass": "vip_with_fixed_coupon",
  "legacyAmount": 100000,
  "newAmount": 103000,
  "difference": 3000,
  "inputFingerprint": "example:8f3a",
  "containsPersonalData": false
}

새 구현이 결제나 메일 같은 부수 효과를 실행하지 않게 한다. 입력·출력 로그는 최소화하고 개인정보 보존 정책을 따른다.

실전 체크리스트

테스트를 만들기 전

테스트를 만들 때

리팩터링할 때

마무리할 때

정리

레거시 코드 리팩터링의 첫 단계는 코드를 더 아름답게 만드는 것이 아니라, 변경으로 달라지는 행동을 볼 수 있게 만드는 것이다.

Characterization Test를 적용하는 원칙은 다음과 같다.

  1. 현재 결과를 올바른 요구사항이 아니라 비교 기준선으로 본다.
  2. 변경하려는 코드를 통과하는 가장 좁고 안정적인 public 경계를 고른다.
  3. 반환값과 중요한 부수 효과를 함께 관찰한다.
  4. 입력 축과 분기를 표로 만들어 대표 사례와 경계값을 수집한다.
  5. 큰 출력에는 golden master를 쓰되 변동값과 민감 정보를 제거한다.
  6. 시간·랜덤·ID·정렬을 명시적으로 제어한다.
  7. 외부 의존성 앞에 최소 seam을 만든 뒤 구조 변경을 시작한다.
  8. DB 제약과 transaction은 실제 테스트 DB에서 결과로 검증한다.
  9. 발견한 버그는 현재 행동과 원하는 행동을 구분해 의도적으로 바꾼다.
  10. differential test와 mutation을 이용해 안전망이 실제로 작동하는지 확인한다.
  11. 리팩터링 뒤 중요한 사례는 정식 회귀 테스트로 남기고 구현 세부 테스트는 정리한다.

Characterization Test는 레거시 코드를 영원히 보존하는 장치가 아니다. 무엇을 보존하고 무엇을 의도적으로 바꿀지 결정할 수 있게 해 주는 관찰 장치다.

관련 노트